import type {
  SourcifyErrorResponse,
  SourcifyLookupResponse,
  SourcifyVerificationStatusResponse,
  SourcifyVerificationResponse,
} from "./sourcify.types.js";
import type {
  VerificationProvider,
  VerificationStatusResponse,
  BaseVerifyFunctionArgs,
  CreateSourcifyOptions,
  ResolveConfigOptions,
} from "./types.js";
import type {
  Dispatcher,
  DispatcherOptions,
  HttpResponse,
} from "@nomicfoundation/hardhat-utils/request";
import type {
  ChainDescriptorsConfig,
  VerificationProvidersConfig,
} from "hardhat/types/config";
import type { CompilerInput } from "hardhat/types/solidity";

import { HardhatError } from "@nomicfoundation/hardhat-errors";
import { ensureError } from "@nomicfoundation/hardhat-utils/error";
import { isObject, sleep } from "@nomicfoundation/hardhat-utils/lang";
import {
  getProxyUrl,
  getRequest,
  postJsonRequest,
  ResponseStatusCodeError,
  shouldUseProxy,
} from "@nomicfoundation/hardhat-utils/request";

export const SOURCIFY_PROVIDER_NAME: keyof VerificationProvidersConfig =
  "sourcify";

const VERIFICATION_STATUS_POLLING_SECONDS = 3;

export const SOURCIFY_API_URL = "https://sourcify.dev/server";

export interface SourcifyVerifyFunctionArgs extends BaseVerifyFunctionArgs {
  creationTxHash?: string;
}

export class Sourcify implements VerificationProvider {
  public readonly chainId: string;
  public readonly name: string;
  public readonly url: string;
  public readonly apiUrl: string;
  public readonly dispatcherOrDispatcherOptions?:
    | Dispatcher
    | DispatcherOptions;
  public readonly pollingIntervalMs: number;

  public static async resolveConfig({
    chainId,
    verificationProvidersConfig,
    dispatcher,
  }: ResolveConfigOptions): Promise<CreateSourcifyOptions> {
    return {
      verificationProviderConfig: verificationProvidersConfig.sourcify,
      chainId,
      dispatcher,
    };
  }

  public static async create({
    verificationProviderConfig,
    chainId,
    dispatcher,
  }: CreateSourcifyOptions): Promise<Sourcify> {
    return new Sourcify({
      chainId,
      apiUrl: verificationProviderConfig.apiUrl,
      dispatcher,
    });
  }

  // Not used by sourcify, but required by the VerificationProvider interface
  public static async getSupportedChains(): Promise<ChainDescriptorsConfig> {
    return new Map();
  }

  constructor(sourcifyConfig: {
    chainId: number;
    name?: string;
    apiUrl?: string;
    dispatcher?: Dispatcher;
  }) {
    this.chainId = String(sourcifyConfig.chainId);
    this.name = sourcifyConfig.name ?? "Sourcify";
    this.apiUrl = sourcifyConfig.apiUrl ?? SOURCIFY_API_URL;
    this.url = `${this.apiUrl}/repo-ui`;

    const proxyUrl = shouldUseProxy(this.apiUrl)
      ? getProxyUrl(this.apiUrl)
      : undefined;
    this.dispatcherOrDispatcherOptions =
      sourcifyConfig.dispatcher ??
      (proxyUrl !== undefined ? { proxy: proxyUrl } : {});

    this.pollingIntervalMs =
      sourcifyConfig.dispatcher !== undefined
        ? 0
        : VERIFICATION_STATUS_POLLING_SECONDS;
  }

  public getContractUrl(address: string): string {
    return `${this.url}/${this.chainId}/${address}`;
  }

  public getVerificationJobUrl(guid: string): string {
    return `${this.apiUrl}/verify-ui/jobs/${guid}`;
  }

  public async isVerified(address: string): Promise<boolean> {
    let response: HttpResponse;
    let responseBody: SourcifyLookupResponse | SourcifyErrorResponse;
    try {
      response = await getRequest(
        `${this.apiUrl}/v2/contract/${this.chainId}/${address}`,
        undefined,
        this.dispatcherOrDispatcherOptions,
      );
      responseBody = await response.body.json();
    } catch (error) {
      ensureError(error);

      if (
        error instanceof ResponseStatusCodeError &&
        isSourcifyLookupResponse(error.body)
      ) {
        // Unverified contracts are returned with status 404
        return error.body.match !== null;
      }

      if (
        error instanceof ResponseStatusCodeError &&
        isSourcifyErrorResponse(error.body)
      ) {
        throw new HardhatError(
          HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.EXPLORER_REQUEST_STATUS_CODE_ERROR,
          {
            name: this.name,
            url: this.apiUrl,
            statusCode: error.statusCode,
            errorMessage: error.body.message,
          },
        );
      }

      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.EXPLORER_REQUEST_FAILED,
        {
          name: this.name,
          url: this.apiUrl,
          errorMessage:
            error.cause instanceof Error ? error.cause.message : error.message,
        },
      );
    }

    if (!isSourcifyLookupResponse(responseBody)) {
      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_VERIFICATION_UNEXPECTED_RESPONSE,
        { message: JSON.stringify(responseBody) },
      );
    }

    return responseBody.match !== null;
  }

  public async verify({
    contractAddress,
    compilerInput,
    contractName,
    compilerVersion,
    creationTxHash,
  }: SourcifyVerifyFunctionArgs): Promise<string> {
    const body: {
      stdJsonInput: CompilerInput;
      contractIdentifier: string;
      compilerVersion: string;
      creationTransactionHash?: string;
    } = {
      stdJsonInput: compilerInput,
      contractIdentifier: contractName,
      compilerVersion,
    };

    if (creationTxHash !== undefined) {
      body.creationTransactionHash = creationTxHash;
    }

    let response: HttpResponse;
    let responseBody: SourcifyVerificationResponse | SourcifyErrorResponse;
    try {
      response = await postJsonRequest(
        `${this.apiUrl}/v2/verify/${this.chainId}/${contractAddress}`,
        body,
        undefined,
        this.dispatcherOrDispatcherOptions,
      );
      responseBody = await response.body.json();
    } catch (error) {
      ensureError(error);

      if (
        error instanceof ResponseStatusCodeError &&
        isSourcifyErrorResponse(error.body)
      ) {
        if (error.body.customCode === "already_verified") {
          throw new HardhatError(
            HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_ALREADY_VERIFIED,
            {
              contract: contractName,
              address: contractAddress,
            },
          );
        }

        throw new HardhatError(
          HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_VERIFICATION_REQUEST_FAILED,
          { message: error.body.message },
        );
      }

      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.EXPLORER_REQUEST_FAILED,
        {
          name: this.name,
          url: this.apiUrl,
          errorMessage:
            error.cause instanceof Error ? error.cause.message : error.message,
        },
      );
    }

    if (!isSourcifyVerificationResponse(responseBody)) {
      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_VERIFICATION_UNEXPECTED_RESPONSE,
        { message: JSON.stringify(responseBody) },
      );
    }

    return responseBody.verificationId;
  }

  public async pollVerificationStatus(
    guid: string,
    contractAddress: string,
    contractName: string,
  ): Promise<{
    success: boolean;
    message: string;
  }> {
    let response: HttpResponse;
    let responseBody:
      | SourcifyVerificationStatusResponse
      | SourcifyErrorResponse;
    try {
      response = await getRequest(
        `${this.apiUrl}/v2/verify/${guid}`,
        undefined,
        this.dispatcherOrDispatcherOptions,
      );
      responseBody = await response.body.json();
    } catch (error) {
      ensureError(error);

      if (
        error instanceof ResponseStatusCodeError &&
        isSourcifyErrorResponse(error.body)
      ) {
        throw new HardhatError(
          HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_VERIFICATION_STATUS_POLLING_FAILED,
          { message: error.body.message },
        );
      }

      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.EXPLORER_REQUEST_FAILED,
        {
          name: this.name,
          url: this.apiUrl,
          errorMessage:
            error.cause instanceof Error ? error.cause.message : error.message,
        },
      );
    }

    if (!isSourcifyVerificationStatusResponse(responseBody)) {
      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_VERIFICATION_UNEXPECTED_RESPONSE,
        { message: JSON.stringify(responseBody) },
      );
    }

    const verificationStatus = new SourcifyVerificationStatus(responseBody);

    if (verificationStatus.isPending()) {
      await sleep(this.pollingIntervalMs);

      return this.pollVerificationStatus(guid, contractAddress, contractName);
    }

    if (verificationStatus.isAlreadyVerified()) {
      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_ALREADY_VERIFIED,
        {
          contract: contractName,
          address: contractAddress,
        },
      );
    }

    if (verificationStatus.isBytecodeMissingInNetworkError()) {
      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_VERIFICATION_MISSING_BYTECODE,
        {
          url: this.apiUrl,
          address: contractAddress,
        },
      );
    }

    if (!(verificationStatus.isFailure() || verificationStatus.isSuccess())) {
      // Reaching this point shouldn't be possible unless the API is behaving in a new way.
      throw new HardhatError(
        HardhatError.ERRORS.HARDHAT_VERIFY.GENERAL.CONTRACT_VERIFICATION_UNEXPECTED_RESPONSE,
        { message: verificationStatus.message },
      );
    }

    const success = verificationStatus.isSuccess();
    let message = verificationStatus.message;
    if (!success) {
      message = `${message}
         More info at: ${this.getVerificationJobUrl(guid)}`;
    }
    return {
      success,
      message,
    };
  }
}

function isSourcifyErrorResponse(
  response: unknown,
): response is SourcifyErrorResponse {
  return (
    isObject(response) &&
    "customCode" in response &&
    "message" in response &&
    "errorId" in response
  );
}

function isSourcifyLookupResponse(
  response: unknown,
): response is SourcifyLookupResponse {
  return (
    isObject(response) &&
    "match" in response &&
    "creationMatch" in response &&
    "runtimeMatch" in response &&
    "chainId" in response &&
    "address" in response
  );
}

function isSourcifyVerificationResponse(
  response: unknown,
): response is SourcifyVerificationResponse {
  return isObject(response) && "verificationId" in response;
}

function isSourcifyVerificationStatusResponse(
  response: unknown,
): response is SourcifyVerificationStatusResponse {
  return (
    isObject(response) &&
    "isJobCompleted" in response &&
    "verificationId" in response &&
    "jobStartTime" in response &&
    "contract" in response
  );
}

class SourcifyVerificationStatus implements VerificationStatusResponse {
  public readonly response: SourcifyVerificationStatusResponse;

  constructor(response: SourcifyVerificationStatusResponse) {
    this.response = response;
  }

  public get message(): string {
    if (!this.response.isJobCompleted) {
      return "Pending in queue";
    }
    if (this.response.error !== undefined) {
      return this.response.error.message;
    }
    return `Contract verified with status "${this.response.contract.match}"`;
  }

  public isPending(): boolean {
    return !this.response.isJobCompleted;
  }

  public isFailure(): boolean {
    return this.response.isJobCompleted && this.response.error !== undefined;
  }

  public isSuccess(): boolean {
    return (
      this.response.isJobCompleted &&
      this.response.error === undefined &&
      this.response.contract.match !== null
    );
  }

  public isBytecodeMissingInNetworkError(): boolean {
    return (
      this.response.isJobCompleted &&
      this.response.error?.customCode === "contract_not_deployed"
    );
  }

  public isAlreadyVerified(): boolean {
    return (
      this.response.isJobCompleted &&
      this.response.error?.customCode === "already_verified"
    );
  }

  /**
   * SourcifyVerificationStatusResponse represents a successful verification,
   * so this always returns true.
   */
  public isOk(): boolean {
    return true;
  }
}
